/*
 * Copyright 2012 Google Inc. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.fieldscope.api;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.HttpCookie;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.List;
import java.util.Map;

import org.json.JSONException;
import org.json.JSONObject;

/**
 * Talks to a Fieldscope server.
 * 
 * @author Travis Keep
 */
public class FieldscopeApi {

  /**
   * This instance talks to the fieldscope test server.
   */
  public static FieldscopeApi TEST_SERVER = new FieldscopeApi("http://test.fieldscope.org/api");

  private static final int BUFFER_SIZE = 2048;
  private static final String UTF_8 = "UTF-8";
  private static final String SESSION_COOKIE_NAME = "sessionid";
  
  private final String urlBase;

  /**
   * Returns a fieldscope API object that uses the given URL base.
   * @param urlBase The base part of the fieldscope API URL. e.g http://test.fieldscope.org/api
   */
 public static FieldscopeApi withUrlBase(String urlBase) {
   return new FieldscopeApi(urlBase);
 }

  /**
   * Returns the observation schema with given name.
   * @param name The name of the schema.
   * @return The observation schema.
   * @throws IOException if network error happens; if schema does not exist;
   *  or if server returns error.
   */
  public Schema getObservationSchema(String name) throws IOException {
    Schema result = getSchema(name, "Observation");
    if (result == null) {
      throw new IOException(String.format("No observation schema for '%s'", name));
    }
    return result;
  }
  
  /**
   * Returns stations in a schema
   * 
   * @param schema the schema name
   * @return A list of stations. If schema doesn't exist, returns empty list.
   * @throws IOException If a network error happens.
   */
  public List<Station> getStations(String schema) throws IOException {
    HttpURLConnection conn = getUrlConnection(String.format("%s/stations", schema));
    try {
      verifyResponseCode(conn, 200);
      return Station.fromJSON(
          Station.newBuilder().setSchema(schema),
          new JSONObject(readFully(conn.getInputStream())));
    } catch (JSONException e) {
      throw new IOException(e);
    } finally {
      conn.disconnect();
    }
  }
  
  /**
   * Fetches all observations within a schema.
   * 
   * @param schema The schema name e.g 'Olympic'.
   * @return Observations arranged by station ID. Key of map is station ID; value of map is
   * modifiable list of observations sorted by observation date in descending order.
   * @throws IOException If network error happens.
   */
  public Map<Long, List<Observation>> getStationObservations(String schema) throws IOException {
    HttpURLConnection conn = getUrlConnection(String.format("%s/observations", schema));
    try {
      verifyResponseCode(conn, 200);
      return Observation.fromJSONByStationId(new JSONObject(readFully(conn.getInputStream())));
    } catch (JSONException e) {
      throw new IOException(e);
    } finally {
      conn.disconnect();
    }
  }
  
  /**
   * Logs user in.
   * 
   * @param user user name
   * @param passwd the password
   * @return A session ID used to place further API Calls
   * @throws LoginFailedException if login credentials are wrong.
   * @throws IOException if server returns error or general network error happens.
   */
  public String login(String user, String passwd) throws IOException {
    HttpURLConnection conn = getUrlConnection("login");
    try {
      OutputStream os = getOutputStream(conn);
      os.write(toBytes(String.format(
          "username=%s&password=%s", urlEncode(user), urlEncode(passwd))));
      verifyResponseCode(conn, 200);
      HttpCookie cookie = extractCookie(conn, SESSION_COOKIE_NAME);
      if (cookie == null) {
        throw new IOException("No login cookie found in response.");
      }
      return cookie.getValue();
    } finally {
      conn.disconnect();
    }
  }
  
  /**
   * Adds a new observation.
   * 
   * @param sessionId The session Id obtained from {@link #login}
   * @param observation the observation to add
   * @return The ID of newly added observation.
   * @throws LoginFailedException if sessionId is invalid.
   * @throws IOException If a network error happens.
   */
  public long addObservation(String sessionId, Observation observation) throws IOException {
    HttpURLConnection conn = getUrlConnection("observations");
    try {
      setSessionCookie(sessionId, conn);
      OutputStream os = getOutputStream(conn);
      String payload = String.format(
          "station_id=%d&collection_date=%s&%s",
          observation.getStationId(),
          urlEncode(TimeUtils.formatPostTime(observation.getTimestamp())),
          toQueryString(observation.getItems()));
      os.write(toBytes(payload));
      verifyResponseCode(conn, 201);
      return Long.valueOf(readFully(conn.getInputStream()));
    } finally {
      conn.disconnect();
    }   
  }
  
  /**
   * Creates a new station in fieldscope server.
   * 
   * @param sessionId Session Id obtained from {@link #login}
   * @param station the station to add. The schema, location, and name fields are required.
   * @return station ID of new station.
   * @throws IllegalArgumentException if station argument is missing required fields.
   * @throws LoginFailedExeption if sessionId is invalid.
   * @throws IOException If network error happens or server returns error.
   */
  public long addStation(String sessionId, Station station)
      throws IOException {
    if (station.getSchema() == null || station.getLocation() == null || station.getName() == null) {
      throw new IllegalArgumentException("Schema, location, and name are required in station object.");
    }
    HttpURLConnection conn = getUrlConnection(String.format("%s/stations", station.getSchema()));
    try {
      setSessionCookie(sessionId, conn);
      OutputStream os = getOutputStream(conn);
      Location location = station.getLocation();
      os.write(toBytes(String.format("name=%s&x=%.6f&y=%.6f",
          urlEncode(station.getName()), location.getLongitude(), location.getLatitude())));
      verifyResponseCode(conn, 201);
      return Long.valueOf(readFully(conn.getInputStream()));
    } finally {
      conn.disconnect();
    }
  }
  
  private List<Schema> getSchemas(String name) throws IOException {
    HttpURLConnection conn = getUrlConnection(String.format("schemas/json/%s", name));
    try {
      verifyResponseCode(conn, 200);
      return Schema.fromJSON(new JSONObject(readFully(conn.getInputStream())));
    } catch (JSONException e) {
      throw new IOException(e);
    } finally {
      conn.disconnect();
    }
  }
  
  private Schema getSchema(String name, String type) throws IOException {
    List<Schema> schemas = getSchemas(name);
    for (Schema schema : schemas) {
      if (schema.getType().equals(type)) {
        return schema;
      }
    }
    return null;
  }
  
  private HttpURLConnection getUrlConnection(String partialUrlStr) throws IOException {
    String urlName = String.format("%s/%s", urlBase, partialUrlStr);
    return (HttpURLConnection) new URL(urlName)
    .openConnection();
  }
  
  private FieldscopeApi(String urlBase) {
    this.urlBase = urlBase;
  }

  private static String toQueryString(Map<?, ?> data) throws IOException {
    StringBuilder builder = new StringBuilder();
    for (Map.Entry<?, ?> entry : data.entrySet()) {
      if (builder.length() > 0) {
        builder.append('&');
      }
    
      builder.append(urlEncode(entry.getKey().toString()));
      builder.append('=');
      builder.append(urlEncode(entry.getValue().toString()));
    }
    return builder.toString();
  }
  
  private static OutputStream getOutputStream(HttpURLConnection conn) throws IOException {
    conn.setRequestMethod("POST");
    conn.setDoOutput(true);
    
    // Android developers guide recommends setting chunk streaming mode for performance reasons.
    // When I try to do this, I receive a 411 status from fieldscope server. Commenting out for now.
    // conn.setChunkedStreamingMode(0);
    return conn.getOutputStream();
  }

  private static void verifyResponseCode(HttpURLConnection conn, int expectedResponseCode)
      throws IOException {
    int responseCode = conn.getResponseCode();
    if (responseCode == expectedResponseCode) {
      return;
    }
    if (responseCode == 401) {
      throw new LoginFailedException();
    }
    throw new IOException(String.format("Got response %d: %s", responseCode, readFully(conn.getErrorStream())));
  }

  private static void setSessionCookie(String sessionId, HttpURLConnection conn) {
    conn.setRequestProperty("Cookie", new HttpCookie(SESSION_COOKIE_NAME, sessionId).toString());
  }

  private static HttpCookie extractCookie(HttpURLConnection conn, String cookieName) {
    Map<String, List<String>> headerFields = conn.getHeaderFields();
    List<String> cookieHeaders = headerFields.get("Set-Cookie");
    if (cookieHeaders == null) {
      return null;
    }
    for (String cookieHeader : cookieHeaders) {
      HttpCookie cookie = HttpCookie.parse(cookieHeader).get(0);
      if (cookie.getName().equals(cookieName)) {
        return cookie;
      }
    }
    return null;
  }

  private static byte[] toBytes(String str) throws UnsupportedEncodingException {
    return str.getBytes(UTF_8);
  }

  private static String urlEncode(String str) throws UnsupportedEncodingException {
    return URLEncoder.encode(str, UTF_8);
  }

  private static String readFully(InputStream is) throws IOException {
    ByteArrayOutputStream os = new ByteArrayOutputStream();
    byte[] buffer = new byte[BUFFER_SIZE];
    for (int numread = is.read(buffer); numread != -1; numread = is.read(buffer)) {
      os.write(buffer, 0, numread);
    }
    return new String(os.toByteArray(), UTF_8);
  }
}
